feat(fsdp): meta-init + rank0-streamed weight loading (--fsdp-load-mode stream) - #39
Merged
Conversation
…de stream)
Every rank used to materialize the full pipeline on CPU
(DiffusionPipeline.from_pretrained), move the whole unsharded model to GPU,
and only then fully_shard — peak GPU = full model (54 GB for one Wan2.2-A14B
expert in fp32 master dtype), N×full disk reads, and no way to honor a meta
init context (diffusers' pipeline loader crashes under one).
New default path (--fsdp-load-mode stream), per component:
build on meta via init_empty_weights(include_buffers=False) # buffers real
-> fully_shard on meta (costs nothing)
-> to_empty: allocate only this rank's shards; carry non-persistent
buffers (Wan rope tables) across the transition
-> rank0 streams safetensors file-by-file, set_model_state_dict
broadcast_from_rank0 scatters into the shards
-> LoRA adapters build on meta too (low_cpu_mem_usage) and initialize
after materialization; data-aware inits (pissa/olora) are rejected
-> family postprocess hook runs the before-fsdp hook after
materialization, where qwen-image's rope parity rebuild has real
CUDA tensors instead of silently no-oping on meta
--fsdp-load-mode legacy keeps the old path for custom pipelines.
Wan2.2-A14B (both experts, fp32, 2×H200): init 285s -> 71s, peak GPU
80.5 -> 54.0 GB (= the sharded params themselves); only rank0 reads the
checkpoint. Streamed weights verified bitwise-equal to from_pretrained
(params + buffers) at 1.3B scale and in the new 2-GPU CI test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rank0 keeps its real shards through .to(cuda) instead of to_empty + re-fill; every buffer is broadcast from rank0 after set_model_state_dict (covers non-persistent buffers absent from any state_dict, instead of trusting recomputed-from-config values); under fsdp_cpu_offload the load happens on GPU and buffers stay there, since CPUOffloadPolicy manages params only. Add short specs to the three loading helpers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep the loading interface (load_component/load_scheduler) enforced at class-definition time rather than first call; the conditionally-required attention hooks stay NotImplementedError as before. Test-local subclasses stub the abstract loaders themselves (also drops the stale load_models_and_scheduler stubs left from the old interface). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts commit 671b9b41; the CI test is still wanted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blings WanPipeline declares transformer/transformer_2 optional with a None default, which drops them from expected_modules: the None passed to DiffusionPipeline.from_pretrained is silently ignored and both 14B experts load from disk on every rank -- during load_scheduler too -- and on non-rank0 (low_cpu_mem_usage=False) trip diffusers' _keep_in_fp32_modules guard, crashing any multi-rank init. Resolve the component class from model_index.json and from_pretrained(subfolder=...) it directly instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zhihengy
force-pushed
the
fix/fsdp-meta-stream-load
branch
2 times, most recently
from
July 20, 2026 21:09
a99b58d to
ed20fd0
Compare
zhihengy
force-pushed
the
fix/fsdp-meta-stream-load
branch
from
July 20, 2026 21:12
ed20fd0 to
d431a23
Compare
zhihengy
marked this pull request as ready for review
July 20, 2026 21:13
zhihengy
added a commit
that referenced
this pull request
Jul 22, 2026
Resolves conflicts with the ModelBackend refactor (#22/#39): - actor.py: keep main's load_scheduler/load_component meta-init path, thread --fsdp-frozen-params-dtype into load_component's master_dtype; keep cast_forward_inputs on apply_fsdp2 - sglang_diffusion_engine.py: keep the num_gpus/tp_size mapping (engine owns its rollout allocation; tp comes from --sglang-tp-size) - cosmos3.py: drop Cosmos3ModelBackend — the default DiffusersModelBackend now loads per-component from model_index.json (Cosmos3OmniTransformer / UniPCMultistepScheduler resolve cleanly); move the UND-freeze + time_embedder cast hook to postprocess_model_after_materialize, the renamed hook site (after FSDP wrap + weight materialization) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
This PR replaces per-rank full-model materialization with rank-0-authoritative, FSDP2-sharded loading. Rank 0 loads real CPU weights; other ranks build meta shells, allocate only their local shards, and receive weights from rank 0. This removes the old full-model H2D copy on every rank, substantially reducing initialization latency and peak GPU memory while preserving bitwise-equivalent base-weight shards.
End-to-end loading flow
low_cpu_mem_usage=Truepath and holds the authoritative real CPU weights. Other ranks run underinit_empty_weights(include_buffers=False)withlow_cpu_mem_usage=False, so parameters remain on meta; their checkpoint state dict may still be read transiently on CPU. LoRA is initialized only on rank 0, while other ranks build meta adapters.to_empty(cuda)to allocate only the other ranks' local shards.set_model_state_dict(..., broadcast_from_rank0=True)fills those shards.Important edge cases and fixes
low_cpu_mem_usage: non-rank-0 must useFalse; diffusers'Truepath materializes parameters by assignment and bypasses the ambient meta context._keep_in_fp32_modules: diffusers rejectslow_cpu_mem_usage=Falsefor classes with fp32-pinned modules. The pin is temporarily cleared only on non-rank-0; rank 0 still honors it, andsync_model_dtypesrestores the authoritative dtype for every meta parameter before sharding.include_buffers=Falselets constructors compute real buffers, butto_emptydiscards their values and non-persistent buffers are absent fromstate_dict. Broadcasting all buffers after parameter loading restores correct, replicated values on every rank.transformer=Noneortransformer_2=NonethroughDiffusionPipeline.from_pretraineddoes not reliably skip them because Wan declares them as optional defaults; theNoneentries are dropped and both 14B experts can be loaded unintentionally, including during scheduler loading. The fix resolves the requested class frommodel_index.jsonand callscls.from_pretrained(..., subfolder=component)directly, so only the named component is touched and the fp32 workaround targets the correct class.Benchmark: meta-init + rank0-broadcast loading vs main (8x H200, cold page cache)
Measured on the real
FSDPTrainRayActor.initmodel-loading path;checkpoint page cache evicted (
posix_fadvise DONTNEED) before every run;latency and peak memory are the max across ranks.
Per-rank shard sha256 digests are bitwise-identical between the two
modes for all four families (same world size, same sharding plan, so the
local shards must match exactly).
Init latency (s) — lower is better
xychart-beta title "init latency (s): main (back) vs stream (front)" x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"] y-axis "seconds" 0 --> 434 bar [37.2, 247.6, 377.0, 68.2] bar [15.8, 120.2, 133.2, 56.3]Peak GPU memory per rank (GB) — lower is better
xychart-beta title "peak GPU/rank (GB): main (back) vs stream (front)" x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"] y-axis "GB" 0 --> 88 bar [9.3, 76.3, 60.1, 35.5] bar [2.9, 10.9, 14.6, 5.2]xychart-beta title "init speedup (x, higher is better)" x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"] y-axis "speedup (x)" 0 --> 3 bar [2.4, 2.1, 2.8, 1.2]xychart-beta title "peak GPU/rank reduction (%)" x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"] y-axis "% reduction" 0 --> 99 bar [68.8, 85.7, 75.7, 85.4]Loading mechanics:
low_cpu_mem_usage, meta init, and_keep_in_fp32_modulesThis is the most confusing corner of the PR, so spelling it out.
diffusers'
from_pretrainedhas two loading paths:low_cpu_mem_usage=True(diffusers' default): self-contained fast path.diffusers builds the module on its own internal meta device, then reads the
checkpoint and materializes every param directly by assignment. It always
produces real weights and bypasses any ambient init context (assignment does
not go through parameter registration, so an external
init_empty_weightscannot intercept it). Peak CPU ~= 1x model size.
low_cpu_mem_usage=False: plain path. Runs the model's ordinary__init__(params allocated through normal registration), then reads thefull state dict and
copy_()s it into the params. In normal use this peaksat ~2x model size -- that is exactly what
Truewas invented to avoid.Why each rank uses what it uses:
low_cpu_mem_usageTrueinit_empty_weightsFalse(forced)__init__allocation is hijacked -> params stay on meta (0 bytes); the state-dict read still happens (transient CPU) butcopy_()into meta tensors is a no-op and the dict is freed. Net result: zero-memory shellsNon-rank0 must use
False: it is the only path whose construction step canbe intercepted by our ambient meta context. With
True, diffusers would handback fully materialized weights on every rank and the meta-shell design (shard
on meta ->
to_emptyonly this rank's shard on GPU -> broadcast from rank0)would silently degenerate into N full copies; the actor guards this with an
explicit "did not honor meta initialization" check.
The conflict: some model classes (e.g.
WanTransformer3DModel) declare_keep_in_fp32_modules-- submodules that must stay fp32 even when the restloads in bf16. diffusers implements that pinning only inside the
Truepath, and rather than silently dropping the pin it raises up-front:
ValueError: low_cpu_mem_usage cannot be False when keep_in_fp32_modules is True. So non-rank0 (forcedFalse) cannot load such a class as-is.How we handle it: on non-rank0 only,
_keep_in_fp32_modulesis temporarilycleared on the class for the duration of the
from_pretrainedcall. This issafe because non-rank0 params are meta placeholders anyway -- authoritative
dtypes come from rank0 (which loads with
Trueand honors the pin), andsync_model_dtypesbroadcasts rank0's per-tensor dtypes to every rank beforesharding, so all shards agree.